using Microsoft.Isam.Esent.Interop;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Serialization;

namespace Microsoft.Isam.Esent.Serialization
{
	/// <summary>Represents a ESENT database.</summary>
	/// <remarks><para>This class is the heart of the whole EsentSerialization library.</para>
	/// <para>It owns the database's JET_INSTANCE, and it owns a collection of the <see cref="TypeSerializer" /> objects for every record type.</para>
	/// <para>You should not attempt to create more then one EseSerializer instance in the same process:
	/// the DB instance name is hard-coded.</para>
	/// <para>In the same process, you may use either EseSerializer, <see cref="SessionPool"/>,
	/// or <see cref="AspSessionPool"/>, but not both.</para>
	/// <para>Here's the recommended C# code for a desktop or console application:</para>
	///<code lang="C#">            // The application entry point
	///static void Main()
	///{
	///	string strDatabasePath = Environment.ExpandEnvironmentVariables( @"%APPDATA%\MyCompany\MySoftware" );
	///	using( EseSerializer serializer = new EseSerializer( strDatabasePath, false, true ) )
	///	using( iSerializerSession sess = serializer.OpenDatabase( true ) )
	///	{
	///		if( serializer.isNewDatabase )
	///		{
	///			// If you need some records to be presented in a newly created DB, add them here.
	///		}
	///
	///		// Run your software; use 'sess' variable to access the DB.
	///	}
	///}</code></remarks>
	public partial class EseSerializer: IDisposable
	{
		readonly string m_instanceName;
		JET_INSTANCE m_idInstance;
		/// <summary></summary>
		public JET_INSTANCE idInstance { get { return m_idInstance; } }

		/// <summary>The folder where the DB is located.</summary>
		public readonly string folderDatabase;

		string m_pathDatabase;
		/// <summary>The full path of the main DB file, which is located inside the folderDatabase.</summary>
		public string pathDatabase { get { return m_pathDatabase; } }

		/// <summary>I no longer remember why the 'j11'.
		/// Feel free to change the file names to whatever you want.</summary>
		const string s_BaseName = "j11";
		/// <summary></summary>
		const string s_FileName = "-j11-main.db";

		readonly bool m_bDatabaseEncrypted;
		/// <summary>True if the DB is NTFS-encrypted</summary>
		public bool bDatabaseEncrypted { get { return m_bDatabaseEncrypted; } }

		readonly object syncRoot = new object();

		/// <summary>Close the DB.</summary>
		public void /* IDisposable. */ Dispose()
		{
			if( JET_INSTANCE.Nil != idInstance )
			{
				Api.JetTerm( idInstance );
				m_idInstance = JET_INSTANCE.Nil;
			}
		}

		/// <summary>Construct the serializer.</summary>
		/// <param name="strFolder">Database folder</param>
		/// <param name="bEncryptDatabase">Set to true to encrypt the database.</param>
		/// <param name="bSubstituteDriveLetter" >Set to true to use <see cref="Subst.Mount">Subst.Mount</see>
		/// before opening the DB.</param>
		/// <param name="typesToAdd">Record types to add.</param>
		public EseSerializer( string strFolder, bool bEncryptDatabase, bool bSubstituteDriveLetter, IEnumerable<Type> typesToAdd )
		{
			m_bDatabaseEncrypted = bEncryptDatabase;

			if( !Directory.Exists( strFolder ) )
				Directory.CreateDirectory( strFolder );

			if( bSubstituteDriveLetter )
				strFolder = Subst.Mount( strFolder );

			folderDatabase = strFolder;

			m_instanceName = "EseSerializer";

			if( null != typesToAdd )
			{
				foreach( Type t in typesToAdd )
				{
					Object[] attrs = t.GetCustomAttributes( typeof( Attributes.EseTableAttribute ), false );
					if( attrs.Length < 1 ) continue;
					if( attrs.Length > 1 ) throw new SerializationException( "The [EseTable] attribute is applied to '" + t.Name + "' type more then once." );

					m_tables.Add( new TypeSerializer( t, attrs[ 0 ] as Attributes.EseTableAttribute ) );
				}
			}

			SetupGlobalParams();
			JET_INSTANCE i;
			Api.JetCreateInstance( out i, m_instanceName );
			SetupInstanceParams( i, strFolder );
			Api.JetInit( ref i );
			m_idInstance = i;
		}

		/// <summary>Construct the serializer.</summary>
		/// <param name="strFolder">Database folder</param>
		/// <param name="bEncryptDatabase">Set to true to encrypt the database.</param>
		/// <param name="typesToAdd">Record types to add.</param>
		public EseSerializer( string strFolder, bool bEncryptDatabase, IEnumerable<Type> typesToAdd ) :
			this( strFolder, bEncryptDatabase, false, typesToAdd ) { }

		/// <summary>Construct the serializer.</summary>
		/// <param name="strFolder">Database folder</param>
		/// <param name="bEncryptDatabase">Set to true to encrypt the database.</param>
		/// <param name="bAddEntryAssemblyRecordTypes">Set to false to add no types to the database at this time.
		/// In this case you'll have to call <see cref="AddSerializedType" /> for each record type you're going to use with ESENT.
		/// Or, set to true to add every type in the main assembly that has <see cref="Attributes.EseTableAttribute" >[EseTable]</see> applied.</param>
		public EseSerializer( string strFolder, bool bEncryptDatabase, bool bAddEntryAssemblyRecordTypes ) :
			this( strFolder, bEncryptDatabase, bAddEntryAssemblyRecordTypes ? Assembly.GetEntryAssembly().GetTypes() : null )
			{}

		/// <summary>This parameter specifies the minimum tuple length in a tuple index.</summary>
		/// <remarks>The default value is 3.<br />
		/// Set this parameter before the creation of EseSerializer, SessionPool or AspSessionPool.</remarks>
		public static int s_paramIndexTuplesLengthMin = 3;

		/// <summary>This enum holds a presets for various DB settings affecting ESENT performance.</summary>
		public enum ePerformanceOptions
		{
			/// <summary>This setting is default, and the only one before ESENT serialization ver. 1.6.</summary>
			/// <remarks>
			/// Page size = default = 4kb<br/>
			/// Log file = 1MB, log buffers = 512kb<br/>
			/// Caching is default = system-managed
			/// </remarks>
			Default,

			/// <summary>Optimized for medium-to-large databases.</summary>
			/// <remarks>
			/// Page size = default = 4kb<br/>
			/// Log file = 16MB, log buffers = 8MB<br/>
			/// Minimum cache = 128 MB RAM, maximum cache = 1.25GB RAM
			/// </remarks>
			OptimizedMedium,
		};

		/// <summary>Set this value before the creation of EseSerializer, SessionPool or AspSessionPool.</summary>
		/// <seealso cref="ePerformanceOptions" />
		public static ePerformanceOptions s_PerformanceOptions = ePerformanceOptions.Default;

		void SetupGlobalParams()
		{
			switch ( s_PerformanceOptions )
			{
				case ePerformanceOptions.Default:
					break;
				case ePerformanceOptions.OptimizedMedium:
					SystemParameters.CacheSizeMin = 16 * 1024;   // = 16k DB pages = 128 MB RAM
					SystemParameters.CacheSizeMax = 160 * 1024; // = 160k DB pages = 1.25 GB RAM
					break;
				default:
					throw new ArgumentException( String.Format( "Unexpected s_PerformanceOptions value {0}", s_PerformanceOptions ) );
			}
		}

		void SetupInstanceParams( JET_INSTANCE i, string strFolder )
		{
			InstanceParameters Parameters = new InstanceParameters( i );

			m_pathDatabase = Path.Combine( strFolder, s_FileName );

			// Mostly copy-pasted from Microsoft.Isam.Esent.Collections.Generic.PersistentDictionary<>.__ctor()
			Parameters.SystemDirectory = strFolder;
			Parameters.LogFileDirectory = strFolder;
			Parameters.TempDirectory = strFolder;
			Parameters.AlternateDatabaseRecoveryDirectory = strFolder;
			Parameters.CreatePathIfNotExist = true;
			Parameters.BaseName = s_BaseName;
			Parameters.EnableIndexChecking = false;
			Parameters.CircularLog = true;
			Parameters.CheckpointDepthMax = 0x4010000;
			Parameters.PageTempDBMin = 0;
			Parameters.MaxVerPages = 0x400;

			switch ( s_PerformanceOptions )
			{
				case ePerformanceOptions.Default:
					Parameters.LogFileSize = 16 * 1024; // in KB
					Parameters.LogBuffers  = 16 * 1024;  // in 512-byte units
					break;
				case ePerformanceOptions.OptimizedMedium:
					Parameters.LogFileSize = 32 * 1024; // in KB
					Parameters.LogBuffers  = 32 * 1024; // in 512-byte units
					break;
				default:
					throw new ArgumentException( String.Format( "Unexpected s_PerformanceOptions value {0}", s_PerformanceOptions ) );
			}

			// Ext. parameters
			Api.JetSetSystemParameter( i, JET_SESID.Nil, Ext.JET_paramIndexTuplesLengthMin, s_paramIndexTuplesLengthMin, null );
		}

		/// <summary>Add the record type to serializer.</summary>
		/// <param name="t">The record type to add.</param>
		/// <returns>False if the type was already added.</returns>
		public bool AddSerializedType( Type t )
		{
			Object[] attrs = t.GetCustomAttributes( typeof( Attributes.EseTableAttribute ), false );
			if( attrs.Length < 1 ) throw new SerializationException( "The type must have [EseTable] attribute applied." );
			if( attrs.Length > 1 ) throw new SerializationException( "The [EseTable] attribute is applied to the type more then once." );

			lock( syncRoot )
			{
				if( m_tables.FindIndex( ts => ts.recordType.Equals( t ) ) >= 0 )
					return false;	// the type is already added.

				m_tables.Add( new TypeSerializer( t, attrs[ 0 ] as Attributes.EseTableAttribute ) );
				return true;
			}
		}

		bool m_bNewDatabase = true;
		/// <summary>True if the new DB has been created by the last OpenDatabase call.</summary>
		public bool isNewDatabase { get { return m_bNewDatabase; } }

		/// <summary>Encrypt or decrypt the database, according to the m_bDatabaseEncrypted value.
		/// By the time of this call, the DB must be created but not opened.</summary>
		void EncryptDecryptDatabase()
		{
			lock( syncRoot )
			{
				Debug.Assert( File.Exists( m_pathDatabase ) );
				bool bAlreadyEncrypted = ( 0 != ( File.GetAttributes( m_pathDatabase ) & FileAttributes.Encrypted ) );

				if( bAlreadyEncrypted && !m_bDatabaseEncrypted )
					File.Decrypt( m_pathDatabase );
				else if( !bAlreadyEncrypted && m_bDatabaseEncrypted )
					File.Encrypt( m_pathDatabase );
			}
		}

		/// <summary>It's crucial to have EnsureDatabaseExists method executed exactly once, otherwise the isNewDatabase value is lost, which is bad esp. if doing schema upgrades.</summary>
		private bool bEnsureDatabaseExistsCalled = false;

		/// <summary>Create the database if it's not exists on the HDD.</summary>
		/// <remarks>Normally, this is called from <see cref="OpenDatabase" />.<br/>
		/// You should only use this method if you're creating database session manually.</remarks>
		public void EnsureDatabaseExists()
		{
			if( bEnsureDatabaseExistsCalled )
				return;

			lock( syncRoot )
			{
				if( bEnsureDatabaseExistsCalled )
					return;
				bEnsureDatabaseExistsCalled = true;

				if( !File.Exists( m_pathDatabase ) )
				{
					CreateDatabase();
					m_bNewDatabase = true;
				}
				else
				{
					EncryptDecryptDatabase();
					m_bNewDatabase = false;
				}
			}
		}

		/// <summary>Open or create the DB,and return the <see cref="iSerializerSession">session</see> interface of the newly created DB session.</summary>
		/// <param name="bOpenAllTables">True to open tables for every record type currently added to the serializer.</param>
		/// <returns>iSerializerSession to access the DB.</returns>
		/// <remarks><para>You should call this method on every thread (including the main thread)
		/// that's going to use the database.</para>
		/// <para>Below is the recommended C# code:</para>
		/// <code lang="C#">            // Your thread procedure
		///void ThreadProc()
		///{
		///	using( iSerializerSession sess = m_serializer.OpenDatabase( false ) )
		///	{
		///		// Open the table in this session ( note we've passed false to the OpenDatabase call )
		///		sess.AddType( typeof( MyRecordClass ) );
		///		
		///		// Run your thread; use 'sess' variable to access the DB.
		///	}
		///}</code></remarks>
		public iSerializerSession OpenDatabase( bool bOpenAllTables )
		{
			EnsureDatabaseExists();

			SerializerSession res = new SerializerSession( this );
			if( bOpenAllTables )
			{
				foreach( var i in m_tables )
				{
					Global.TryCatch( delegate() { res.addType( i, false ); }, "Unable to open ESE table '" + i.tableName + "'." );
				}
			}
			return res;
		}

		// Mostly copy-pasted from Microsoft.Isam.Esent.Collections.Generic.PersistentDictionary<>.CreateDatabase()
		void CreateDatabase()
		{
			using( Session session = new Session( idInstance ) )
			{
				JET_DBID idDatabase;
				Api.JetCreateDatabase( (JET_SESID)session, m_pathDatabase, string.Empty, out idDatabase, CreateDatabaseGrbit.None );
				try
				{
					using( Transaction transaction = new Transaction( (JET_SESID)session ) )
					{
						foreach( var ts in m_tables )
							ts.CreateTableAndIndices( session, idDatabase );

						transaction.Commit( CommitTransactionGrbit.None );
						Api.JetCloseDatabase( (JET_SESID)session, idDatabase, CloseDatabaseGrbit.None );
						Api.JetDetachDatabase( (JET_SESID)session, m_pathDatabase );
					}
				}
				catch( Exception ex )
				{
					Api.JetCloseDatabase( (JET_SESID)session, idDatabase, CloseDatabaseGrbit.None );
					Api.JetDetachDatabase( (JET_SESID)session, m_pathDatabase );
					File.Delete( m_pathDatabase );
					throw new ApplicationException( "Unable to create the database.", ex );
				}
			}

			// Encrypt the newly created database, if asked to.
			EncryptDecryptDatabase();
		}

		List<TypeSerializer> m_tables = new List<TypeSerializer>();

		/// <summary>Find the TypeSerializer for the specified record type.</summary>
		/// <param name="t"></param>
		/// <returns></returns>
		public TypeSerializer FindSerializerForType( Type t )
		{
			lock( syncRoot )
				return m_tables.Where( tbl => tbl.recordType.Equals( t ) ).FirstOrDefault();
		}
	}

	static class Global
	{
#if (DEBUG)
		/// <summary>In debug builds we don't re-throw anything.
		/// Using all power of Visual Studio debugger right when it's needed makes the debugging fun again.
		/// Same behaviour can be achieved by checking "Thrown" in Debug->Exceptions, however the .NET XML serializer used internally throws exceptions even while being constructed normally.</summary>
		public static void TryCatch( Action act, string exText ) { act(); }
#else
		/// <summary>In release builds, we properly stack the exceptions inside each other, to store them in the crash log.</summary>
		/// <param name="act"></param>
		/// <param name="exText"></param>
		public static void TryCatch( Action act, string exText ){ try { act(); } catch( System.Exception ex ) { throw new SerializationException( exText, ex ); } }
#endif
	}

	static class Params
	{
		/// <summary>Column compression is only available in Windows 7.
		/// Set this value to 'false' if you're going to copy a database created in Windows 7 to lower Windows versions,
		/// which is AFAIK not supported anyway because ESE engine silently upgrades the database when you move it to a newer Windows, and never downgrades it back.
		/// So just leave this setting to be 'true'.</summary>
		public const bool bUseColumnCompressionIfAvailable = true;
	}
};